Skip to content

Conversation

@sm-sayedi
Copy link
Collaborator

@sm-sayedi sm-sayedi commented Oct 8, 2025

TODOs:

  • Adding some dartdocs and comments
  • Tests

Fixes-partly: #124 (topic link autocomplete will be its own PR)

Screenshots
Empty query A query written Option chosen
1  Empty query 2  Query written 3  Option chosen
Autocompleting channels with problematic characters Channel with problematic characters chosen Message sent
4  Autocompleting channels with problematic characters 5  Channel with problematic characters chosen 6  Message sent
Screen recording
Screen.Recording.2025-10-08.at.9.08.43.PM.mov

@gnprice
Copy link
Member

gnprice commented Oct 9, 2025

Exciting! Thanks for building this.

Just to record here what I said on the team call yesterday: for this PR, we can start the reviews in parallel with you writing the tests. So I'd suggest going ahead and adding those docs and comments next — then once you consider the PR all ready except for the tests, just mention that here and add the "maintainer review" label.

@sm-sayedi sm-sayedi force-pushed the 124-channel-link-autocomplete branch from 5a8171d to 6671cfa Compare October 9, 2025 21:00
@sm-sayedi
Copy link
Collaborator Author

Thanks @gnprice for mentioning this. This is now ready for an initial review. (While working on the first todo, I realized that there were a few other places that needed some changes, which caused a delay 😀)

@sm-sayedi sm-sayedi marked this pull request as ready for review October 9, 2025 21:07
@gnprice gnprice added the maintainer review PR ready for review by Zulip maintainers label Oct 9, 2025
@sm-sayedi sm-sayedi requested a review from chrisbobbe October 9, 2025 21:07
@sm-sayedi sm-sayedi force-pushed the 124-channel-link-autocomplete branch from 6671cfa to fe25a32 Compare October 10, 2025 05:28
@sm-sayedi
Copy link
Collaborator Author

(just rebased atop main with conflicts resolved)

@sm-sayedi sm-sayedi force-pushed the 124-channel-link-autocomplete branch from fe25a32 to 480e787 Compare October 11, 2025 12:00
@sm-sayedi sm-sayedi force-pushed the 124-channel-link-autocomplete branch 2 times, most recently from 6b2fb06 to 5072dce Compare October 21, 2025 19:38
@sm-sayedi
Copy link
Collaborator Author

@chrisbobbe Pushed a new revision with tests included.

@chrisbobbe
Copy link
Collaborator

Thanks for this, and apologies for my delay in reviewing! Here's a review of the first six commits:

05c8437 channel: Add isRecentlyActive field
5a6b96a api: Update ChannelDeleteEvent to match new API changes
607dc8b basic: Add tryCast method
c9d282e store: Call AutocompleteViewManager.handleUserGroupRemove/UpdateEvent
c5f6c77 autocomplete [nfc]: Remove User(Group) params from _rankUser(Group)Result
a459122 autocomplete [nfc]: Move _matchName up to AutocompleteQuery

plus some comments on later commits where I happened to see something interesting. 🙂

Copy link
Collaborator

@chrisbobbe chrisbobbe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh no—thanks for the ping in DMs, somehow I didn't actually submit that review yesterday! Here it is.

historyPublicToSubscribers: historyPublicToSubscribers ?? true,
messageRetentionDays: messageRetentionDays,
channelPostPolicy: channelPostPolicy ?? ChannelPostPolicy.any,
isRecentlyActive: isRecentlyActive ?? false,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think true might be a more natural default value for this?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to true. The false default value was to make the tests in "ranking across signals" and "final results end-to-end" of "ChannelLinkAutocompleteView" group a little less verbose.🙂

@JsonKey(name: 'stream_post_policy')
ChannelPostPolicy? channelPostPolicy; // TODO(server-10) remove
// final bool isAnnouncementOnly; // deprecated for `channelPostPolicy`; ignore
bool? isRecentlyActive; // TODO(server-10)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

channel: Add isRecentlyActive field

Since we're already not matching the order in the API doc (see e.g. #1894 (comment) ), I'd put this next to the related-looking field streamWeeklyTraffic, perhaps just above it without an empty line in between.

Similarly elsewhere in this commit.

required super.id,
required this.streams,
required this.streamIds,
}) : assert(streams != null || streamIds != null);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We try to reserve assert for invariants that are our own responsibility, i.e. those that won't be broken except for some broken logic in the client. Here, the invariant exists, but it's one that can be broken by something out of our control, in particular a badly-behaving server.

Also, asserts don't run in production, so this won't work as "crunchy shell" validation. It makes sense to want such validation, so the "soft center" of the app can rely on this invariant. But let's do it in ChannelDeleteEvent.fromJson; for an example to follow, see DeleteMessageEvent.fromJson.


final List<ZulipStream> streams;
final List<ZulipStreamId>? streams; // TODO(server-10): remove
final List<int>? streamIds; // TODO(server-10): remove nullability
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we'd normally say TODO(server-10) make required or just TODO(server-10).

streams.remove(stream.streamId);
streamsByName.remove(stream.name);
subscriptions.remove(stream.streamId);
final channelIds = event.streamIds ?? event.streams!.map((e) => e.streamId);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh actually, we can make this nicer by encapsulating this conditional in the API-binding layer—ChannelDeleteEvent can have just final List<int> streamIds (maybe channelIds, as the more modern name?), and it can read its value depending on what the JSON looks like.

Something like this (untested)?:

/// A [ChannelEvent] with op `delete`: https://zulip.com/api/get-events#stream-delete
@JsonSerializable(fieldRename: FieldRename.snake)
class ChannelDeleteEvent extends ChannelEvent {
  @override
  @JsonKey(includeToJson: true)
  String get op => 'delete';

  @JsonKey(readValue: _readChannelIds)
  final List<int> channelIds;

  // TODO(server-10) simplify away; rely on stream_ids
  static List<int> _readChannelIds(Map<dynamic, dynamic> json, String key) {
    final channelIds = json['stream_ids'] as List<int>?;
    if (channelIds != null) return channelIds;

    final channels = json['streams'] as List<dynamic>;
    return channels
      .map((c) => (c as Map<String, dynamic>)['stream_id'] as int)
      .toList();
  }

  ChannelDeleteEvent({
    required super.id,
    required this.channelIds,
  });

  factory ChannelDeleteEvent.fromJson(Map<String, dynamic> json) =>
    _$ChannelDeleteEventFromJson(json);

  @override
  Map<String, dynamic> toJson() => _$ChannelDeleteEventToJson(this);
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(In that code, the crunchy-shell validation is done by the final channels = json['streams'] as List<dynamic>; line, which will throw if both .stream_ids and .streams are absent in the json.)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to the new version. One thing that this does is that in toJson, there will be an entry with key channel_ids; not exactly what the server gives us (stream_ids or streams). Should we edit the toJson method to match the server data, or is it not that important since we don't use it to send it back to the server?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh, so there was a test failing in test/model/store_test.dart and the fix was to include streams in toJson.

overflow: TextOverflow.ellipsis,
color: designVariables.contextMenuItemMeta)),
child: BlockContentList(
nodes: parseContent(channel!.renderedDescription).nodes),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neat! Unfortunately we're not ready to show the rendered channel description; some of our content widgets will break if they appear outside the context of a message, because they use InheritedMessage.of(context), and we need to address that systematically, which is #488. See related issues:

For now let's do as I did in #1877:

  • Not try to show the channel description here
  • File an issue for it and leave a TODO

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filed #1945. Also, looking at https://github.com/zulip/zulip-flutter/blob/main/lib/widgets/content.dart, it seems like InheritedMessage.of(context) is used in two places, MessageImagePreview and MessageInlineVideo, and by manually testing, it seems like the server is not sending the related HTML for rendering these widgets when there is an image or video in the channel description. So I think it will be safe to show the channel description now. But as it’s possible that InheritedMessage.of(context) will be used in other widgets, it's good to wait for #488 as you mentioned.

Comment on lines +1536 to +1587
// Behavior we have that web doesn't and might like to follow:
// - A "word-prefixes" match quality on channel names:
// see [NameMatchQuality.wordPrefixes], which we rank on.
//
// Behavior web has that seems undesired, which we don't plan to follow:
// - A "word-boundary" match quality on channel names:
// special rank when the whole query appears contiguously
// right after a word-boundary character.
// Our [NameMatchQuality.wordPrefixes] seems smarter.
// - Ranking some case-sensitive matches differently from case-insensitive
// matches. Users will expect a lowercase query to be adequate.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Am I reading web's sort_streams correctly that it also considers channel descriptions in the filtering and ranking? I don't personally think we need to do that, but it probably deserves a mention here.

Comment on lines 1439 to 1447
return switch((tryCast<Subscription>(a), tryCast<Subscription>(b))) {
(Subscription(), null) => -1,
(null, Subscription()) => 1,
(Subscription(isMuted: false), Subscription(isMuted: true)) => -1,
(Subscription(isMuted: true), Subscription(isMuted: false)) => 1,
(Subscription(pinToTop: true), Subscription(pinToTop: false)) => -1,
(Subscription(pinToTop: false), Subscription(pinToTop: true)) => 1,
_ => 0,
};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer not to add and use tryCast for this, and instead do something like:

Suggested change
return switch((tryCast<Subscription>(a), tryCast<Subscription>(b))) {
(Subscription(), null) => -1,
(null, Subscription()) => 1,
(Subscription(isMuted: false), Subscription(isMuted: true)) => -1,
(Subscription(isMuted: true), Subscription(isMuted: false)) => 1,
(Subscription(pinToTop: true), Subscription(pinToTop: false)) => -1,
(Subscription(pinToTop: false), Subscription(pinToTop: true)) => 1,
_ => 0,
};
if (a is Subscription && b is! Subscription) return -1;
if (a is! Subscription && b is Subscription) return 1;
return switch((a, b)) {
(Subscription(isMuted: false), Subscription(isMuted: true)) => -1,
(Subscription(isMuted: true), Subscription(isMuted: false)) => 1,
(Subscription(pinToTop: true), Subscription(pinToTop: false)) => -1,
(Subscription(pinToTop: false), Subscription(pinToTop: true)) => 1,
_ => 0,
};

which is equivalent and doesn't add a step for the reader to interpret where null comes from and what it means. It also separates the main, headline logic from the rest, corresponding to the dartdoc's choice of what goes in its first line vs. the body:

  /// Comparator that puts subscribed channels before unsubscribed ones.
  ///
  /// For subscribed channels, it puts them in the following way:
  ///   pinned unmuted > unpinned unmuted > pinned muted > unpinned muted

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(also nit: s/way/order/ in that dartdoc)

Comment on lines 1466 to 1472
/// Comparator that puts channels with more weekly traffic first.
///
/// A channel with undefined weekly traffic (`null`) is put after the channel
/// with a weekly traffic defined (even if it is zero).
///
/// Weekly traffic is the average number of messages sent to the channel per
/// week, which is determined by [ZulipStream.streamWeeklyTraffic].
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// Comparator that puts channels with more weekly traffic first.
///
/// A channel with undefined weekly traffic (`null`) is put after the channel
/// with a weekly traffic defined (even if it is zero).
///
/// Weekly traffic is the average number of messages sent to the channel per
/// week, which is determined by [ZulipStream.streamWeeklyTraffic].
/// Comparator that puts channels with more [ZulipStream.streamWeeklyTraffic] first.
///
/// A channel with undefined weekly traffic (`null`) is put after the channel
/// with a weekly traffic defined (even if it is zero).

This is a very reasonable definition of "weekly traffic" 🙂 and so isn't likely to bitrot i.e. become incorrect over time. But since we're just using ZulipStream.streamWeeklyTraffic directly (no computations on it), let's leave that field's definition as the single place where we write its definition, so we only have to change that one thing if the meaning changes.

…I see that we haven't actually written down the field's meaning, which we might've done in dartdoc on the field. But that's quite normal and appropriate; by leaving it blank, we mean to defer to the API documentation, which is linked in the class ZulipStream dartdoc.

eg.stream(streamId: 5, name: 'UI [v2]'),
eg.stream(streamId: 6, name: r'Save $$'),
];
store.addStreams(channels);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be awaited; similarly in a few other places.

Thanks to the discarded_futures lint for catching this, actually; I was playing with it for #731 this morning 🙂

In the following commits, this will be used as one of the criteria for
sorting channels in channel link autocomplete.
@sm-sayedi sm-sayedi force-pushed the 124-channel-link-autocomplete branch from 5072dce to 8cde339 Compare October 24, 2025 18:20
There are new changes made to `stream op: delete` event in server-10:
  - The `streams` field which used to be an array of the just-deleted
    channel objects is now an array of objects which only contains ID
    of the just-deleted channels (this would crash the app before
    this commit).
  - The same `streams` field is also deprecated and will be removed in a
    future release.
  - As a replacement to `streams`, `stream_ids` is introduced which is
    an array of the just-deleted channel IDs.
These two methods were introduced but never called.
This was missed to be added in AutocompleteViewManager.reassemble.
@sm-sayedi sm-sayedi force-pushed the 124-channel-link-autocomplete branch from 8cde339 to ea64c45 Compare October 24, 2025 19:42
@sm-sayedi
Copy link
Collaborator Author

Thanks @chrisbobbe for the review. Pushed new changes, PTAL.

@sm-sayedi sm-sayedi requested a review from chrisbobbe October 24, 2025 19:54
Also, generalize the dartdoc of NameMatchQuality.

For almost all types of autocompletes, the matching mechanism/quality
to an autocomplete query seems to be the same with rare exceptions
(at the time of writing this, only the emoji autocomplete matching
mechanism is different).
As of this commit, it's not yet possible in the app to initiate a
channel link autocomplete interaction. So in the widgets code that would
consume the results of such an interaction, we just throw for now,
leaving that to be implemented in a later commit.
…rseMarkedText

This was first added in 0886948, but seems to have been
accidentally removed in 046ceab.
For this commit we temporarily intercept the query at the
AutocompleteField widget, to avoid invoking the widgets that are
still unimplemented. That lets us defer those widgets' logic to a
separate later commit.
This will make it easy to use the fragment string in several other
places, such as in the next commits where we need to create a fallback
markdown link for a channel.
@sm-sayedi sm-sayedi force-pushed the 124-channel-link-autocomplete branch from ea64c45 to d1abf20 Compare October 24, 2025 20:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

maintainer review PR ready for review by Zulip maintainers

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants